package com.magenta.maxoptra.gis.distancematrix.builder;

import com.magenta.maxoptra.gis.GeoProviderSettings;
import com.magenta.maxoptra.gis.GeoProviderStatus;
import com.magenta.maxoptra.gis.commons.Pair;
import com.magenta.maxoptra.gis.distancematrix.DistanceMatrixCoordinate;
import com.magenta.maxoptra.gis.distancematrix.RoutesIterator;
import com.magenta.maxoptra.gis.distancematrix.UpdateResultType;
import com.magenta.maxoptra.gis.util.Apis;
import org.jetbrains.annotations.Nullable;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Iterate by list of groups and routes
 * <p/>
 * Not thread safe
 *
 * @param <GID> type of group id
 * @author Sergey Grachev
 */
public final class MatrixBuildTask<GID> implements Runnable {
    /**
     * Delay if all providers is busy (milliseconds)
     */
    public static final int BUSY_TIMEOUT = 5000;

    /**
     * Delay between requests of routes (milliseconds)
     */
    public static final int REQUEST_TIMEOUT = 10;

    /**
     * Max count of error (one by one) for provider. After this limit all requests to provider will be ignored
     */
    public static final int MAX_ERRORS = 5000;

    /**
     * Delay between progress updates
     */
    public static final int PROGRESS_UPDATE_DELAY = 2000;

    private static final Comparator<MatrixGroup> HIGH_PRIORITY_MATRICES_COMPARATOR = new Comparator<MatrixGroup>() {
        @Override
        public int compare(final MatrixGroup o1, final MatrixGroup o2) {
            return o1.getPriority() == o2.getPriority() ? 0 : (o1.getPriority() > o2.getPriority() ? -1 : 1);
        }
    };

    private final com.magenta.maxoptra.gis.commons.Logger logger = new com.magenta.maxoptra.gis.commons.Logger(getClass());
    private final List<GID> groupsIds;
    private final int busyTimeout;
    private final int requestTimeout;
    private final int maxErrors;
    private final int progressUpdateDelay;
    private final List<MatrixGroup<GID>> groupsHighPriority = new ArrayList<MatrixGroup<GID>>();
    private final Map<GID, MatrixGroup<GID>> allGroups = new ConcurrentHashMap<GID, MatrixGroup<GID>>();
    private final Map<Long, List<MatrixGroup<GID>>> groups = new ConcurrentHashMap<Long, List<MatrixGroup<GID>>>();
    private final Map<Long, GeoProviderStatus> providers = new HashMap<Long, GeoProviderStatus>();
    private final GeoProviderUsage<Long> providerUsage = new GeoProviderUsage<Long>();
    private final MatrixBuildTaskListener<GID> listener;
    private final Set<GID> removedGroups = new HashSet<GID>(0);
    private final Set<GID> notFinishedRoutesOfGroupsWithErrors = new HashSet<GID>(0);

    private boolean waitBusyProviders = false;
    private boolean hasErrors = false;

    private MatrixBuildTask(final List<GID> groupIds, final MatrixBuildTaskListener<GID> listener,
                            final int busyTimeout, final int requestTimeout, final int maxErrors,
                            final int progressUpdateDelay) {
        this.listener = listener;
        this.groupsIds = groupIds;
        this.busyTimeout = busyTimeout;
        this.requestTimeout = requestTimeout;
        this.maxErrors = maxErrors;
        this.progressUpdateDelay = progressUpdateDelay;
    }

    /**
     * @param groupIds list of groups(accounts) for building distance matrix
     * @param listener any events will be sent via this listener
     * @param <GID>    type of group id
     * @return new task
     */
    public static <GID> MatrixBuildTask<GID> newInstance(final List<GID> groupIds, final MatrixBuildTaskListener<GID> listener) {
        return newInstance(groupIds, listener, BUSY_TIMEOUT, REQUEST_TIMEOUT, MAX_ERRORS, PROGRESS_UPDATE_DELAY);
    }

    /**
     * @param groupIds            list of groups(accounts) for building distance matrix
     * @param listener            any events will be sent via this listener
     * @param busyTimeout         delay if all providers is busy (milliseconds). Default {@link MatrixBuildTask#BUSY_TIMEOUT}
     * @param requestTimeout      delay between requests of routes (milliseconds). Default {@link MatrixBuildTask#REQUEST_TIMEOUT}
     * @param maxErrors           max count of error for provider. After this limit all requests to provider will be ignored. Default {@link MatrixBuildTask#MAX_ERRORS}
     * @param progressUpdateDelay delay between progress updates
     * @param <GID>               type of group id
     * @return new task
     */
    public static <GID> MatrixBuildTask<GID> newInstance(final List<GID> groupIds, final MatrixBuildTaskListener<GID> listener,
                                                         final int busyTimeout, final int requestTimeout,
                                                         final int maxErrors, final int progressUpdateDelay) {
        return new MatrixBuildTask<GID>(groupIds, listener, busyTimeout, requestTimeout, maxErrors, progressUpdateDelay);
    }

    private void fetchGroupsAndGeoProviderSettings() {
        allGroups.clear();

        if (groupsIds.isEmpty()) {
            return;
        }

        for (final GID groupId : groupsIds) {
            addGroup(groupId);
        }
    }

    public void addGroup(final GID groupId) {
        final Settings settings = onFetchSettings(groupId);

        if (settings == null || settings.getGeoProviderSettings() == null) {
            // useless without geo settings
            logger.debug("No geo provider settings for group [" + groupId + "]");
            onFinishGroup(groupId, UpdateResultType.NO_GEO_PROVIDER_SETTINGS);
            return;
        }

        final GeoProviderSettings providerSettings = settings.getGeoProviderSettings();

        final MatrixGroup<GID> group = MatrixGroup.newInstance(
                groupId, providerSettings,
                settings.getRequestsDelay() != null ? settings.getRequestsDelay() : requestTimeout,
                settings.getCoordinatesLimitPerBatchUpdate() != null ? settings.getCoordinatesLimitPerBatchUpdate() : Integer.MAX_VALUE,
                settings.getAllowBatchUpdates(), settings.getPriority());

        if (group.getPriority() > 0) { // should be updated first
            synchronized (groupsHighPriority) {
                groupsHighPriority.add(group);
            }
        } else {
            synchronized (groups) {
                final Long cid = group.getProviderCid();
                List<MatrixGroup<GID>> providerGroups = groups.get(cid);
                if (providerGroups == null) {
                    providerGroups = new ArrayList<MatrixGroup<GID>>(1);
                    groups.put(cid, providerGroups);
                    providerUsage.addProvider(cid);
                    logger.trace(String.format("Add new provider '%s' CID[%s]", providerSettings.getProviderName(), cid));
                }

                providerGroups.add(group);
                allGroups.put(group.getGroupId(), group);
            }
        }
    }

    @Nullable
    private MatrixGroup<GID> next() {
        waitBusyProviders = false;

        // first try update matrices with high priority

        synchronized (groupsHighPriority) {
            if (!groupsHighPriority.isEmpty()) {
                final MatrixGroup<GID> result = nextGroup(groupsHighPriority);
                if (result != null) {
                    return result;
                }
            }
        }

        // from less to most busy

        for (final Long cid : providerUsage.listOfProviders()) {
            final List<MatrixGroup<GID>> providerGroups = groups.get(cid);

            if (providerGroups.isEmpty()) {
                // all finished for this provider
                logger.trace(String.format("No available groups for provider CID[%s]", cid));
                continue;
            }

            final GeoProviderStatus status = providers.get(cid);

            if (status != null) {
                if (status.isProviderStillBusy()) {
                    waitBusyProviders = true;
                    logger.debug(String.format("Provider CID[%s] is busy", cid));
                    continue;
                } else if (!status.isProviderError() && status.isWasRequestToProvider()) {
                    providerUsage.resetErrors(cid);
                }
            }

            final MatrixGroup<GID> result = nextGroup(providerGroups);
            if (result != null) {
                return result;
            }
        }

        return null;
    }

    @Nullable
    private MatrixGroup<GID> nextGroup(final List<MatrixGroup<GID>> providerGroups) {
        final Iterator<MatrixGroup<GID>> iterator = providerGroups.iterator();
        while (iterator.hasNext()) {
            final MatrixGroup<GID> group = iterator.next();
            final GID groupId = group.getGroupId();

            if (!listener.onCheckIsGroupActive(groupId) || removedGroups.contains(groupId)) {
                iterator.remove();
                allGroups.remove(groupId);
                onFinishGroup(groupId, UpdateResultType.INTERRUPTED);
                continue;
            }

            if (!group.isCoordinatesFetched()) {
                final RoutesIterator routesIterator = listener.onFetchCoordinates(groupId);
                if (routesIterator == null || !routesIterator.hasNext()) {
                    iterator.remove();
                    allGroups.remove(groupId);
                    onFinishGroup(groupId, UpdateResultType.NO_COORDINATES);
                    continue;
                }
                group.setRoutesIterator(routesIterator);
            }

            if (!group.hasNextRoute()) { // finished
                iterator.remove();
                allGroups.remove(groupId);
                onFinishGroup(groupId, group.isWithErrors() ? UpdateResultType.UPDATED_WITH_ERRORS : UpdateResultType.UPDATED);
                continue;
            }

            return group;
        }

        return null;
    }

    private void updateProviderStatus(final long providerCid, final GeoProviderStatus status) {
        providers.put(providerCid, status);

        if (status.isProviderBusy()) {
            providerUsage.hitBusy(providerCid);
        } else if (status.isProviderIdle()) {
            providerUsage.hitIdle(providerCid);
        } else if (status.isProviderError()) {
            providerUsage.hitError(providerCid);
        } else {
            return;
        }

        logger.trace(String.format("Update statistics for CID[%s] statistics %s", providerCid, providerUsage));
    }

    @Override
    public void run() {
        if (listener == null) {
            throw new IllegalStateException("Listener is null");
        }

        fetchGroupsAndGeoProviderSettings();

        final Set<Long> usedProviders = new HashSet<Long>(2);

        MatrixGroup<GID> group = next();
        while (Thread.currentThread().isAlive()) {
            if (group != null) {

                // send requests to providers (primary and fail-safe)

                usedProviders.clear();
                GeoProviderSettings settings = group.getGeoProviderSettings();
                while (settings != null) {
                    final long cid = Apis.cid(settings.getProviderName(), settings.getVersionApi(), settings.getCidDiscriminator());
                    if (!usedProviders.contains(cid)) {
                        usedProviders.add(cid);
                    }

                    if (updateWithProvider(group, cid, settings)) {
                        break;
                    }

                    settings = settings.getFailSafeProvider();
                }

                // send progress information if time is coming

                if (group.checkAndSetReadyForProgressUpdate(progressUpdateDelay)) {
                    onProgress(group);
                }

                // timeout between requests

                try {
                    Thread.sleep(group.getRemainingRequestsDelay());
                } catch (InterruptedException ignore) {
                    break;
                }

            } else if (waitBusyProviders) {
                logger.debug("No more providers in a idle state, by now we wait...");

                try {
                    Thread.sleep(busyTimeout);
                } catch (InterruptedException ignore) {
                    break;
                }
            } else {
                logger.debug("All finished");
                break;
            }

            group = next();
        }
    }

    private boolean updateWithProvider(final MatrixGroup<GID> group, final long cid, final GeoProviderSettings settings) {
        final GID groupId = group.getGroupId();

        GeoProviderStatus status;

        // try use batch request if is supported

        if (group.isAllowBatchUpdates() && !group.isAwaitProviderAvailabilityRoute()) {

            final Pair<DistanceMatrixCoordinate, List<DistanceMatrixCoordinate>> routes;
            if (group.isAwaitProviderAvailabilityRoutes()) {
                routes = group.getAwaitRoutes();
            } else {
                group.saveRoutesIteratorState();
                routes = group.nextMatrix(group.getCoordinatesLimitPerBatchUpdate());
            }

            status = onUpdateRoutes(settings, routes, group.getPriority() > 0);
            if (!status.isProviderBusy()) {
                // clear
                group.awaitProviderAvailabilityRoutes(null);
            }

            // batch requests failed

            if (status.isProviderError()) {
                group.restoreRoutesIteratorState();

                // update using standard way, one by one

                final Pair<DistanceMatrixCoordinate, DistanceMatrixCoordinate> route = group.nextRoute();
                status = onUpdateRoute(settings, route, group.getPriority() > 0);
                if (status.isProviderBusy()) {
                    group.awaitProviderAvailabilityRoute(route);
                }
            } else if (status.isProviderBusy()) {
                group.awaitProviderAvailabilityRoutes(routes);
            }

        } else {

            final Pair<DistanceMatrixCoordinate, DistanceMatrixCoordinate> route;
            if (group.isAwaitProviderAvailabilityRoute()) {
                route = group.getAwaitRoute();
            } else {
                route = group.nextRoute();
            }

            status = onUpdateRoute(settings, route, group.getPriority() > 0);
            if (status.isProviderBusy()) {
                group.awaitProviderAvailabilityRoute(route);
            } else {
                // clear
                group.awaitProviderAvailabilityRoute(null);
            }

        }

        logger.trace(String.format("Route updated: status = %s, group = %s", status, group));

        updateProviderStatus(cid, status);

        if (status.isProviderError()) {
            processErrorForGroup(group);
        }

        if (group.hasFinishedUpdateForCoordinates()) {
            updateFinishedCoordinates(group, groupId);
        }

        return !status.isProviderError();
    }

    private void onProgress(final MatrixGroup<GID> group) {
        try {
            listener.onProgress(group.getGroupId(), group.getTotalCoordinates(), group.getCurrentCoordinateIndex());
        } catch (Exception e) {
            logger.trace("onProgress", e);
        }
    }

    private void onFinishGroup(final GID groupId, final UpdateResultType result) {
        try {
            listener.onFinishGroup(groupId, result);
        } catch (Exception e) {
            logger.trace("onFinishGroup", e);
        }
    }

    private GeoProviderStatus onUpdateRoute(final GeoProviderSettings settings,
                                            final Pair<DistanceMatrixCoordinate, DistanceMatrixCoordinate> route,
                                            final boolean highPriority) {
        try {
            return listener.onUpdateRoute(settings, route, highPriority);
        } catch (Exception e) {
            logger.trace("onUpdateRoute", e);
        }
        return GeoProviderStatus.error();
    }

    private Settings onFetchSettings(final GID groupId) {
        try {
            return listener.onFetchSettings(groupId);
        } catch (Exception e) {
            logger.trace("onFetchSettings", e);
        }
        return null;
    }

    private GeoProviderStatus onUpdateRoutes(final GeoProviderSettings settings,
                                             final Pair<DistanceMatrixCoordinate, List<DistanceMatrixCoordinate>> routes,
                                             final boolean highPriority) {
        try {
            return listener.onUpdateRoute(settings, routes.getFirst(), routes.getSecond(), highPriority);
        } catch (Exception e) {
            logger.trace("onUpdateRoutes", e);
        }
        return GeoProviderStatus.error();
    }

    private void processErrorForGroup(final MatrixGroup<GID> group) {
        final GID groupId = group.getGroupId();
        final long cid = group.getProviderCid();

        notFinishedRoutesOfGroupsWithErrors.add(groupId);
        hasErrors = true;
        group.markWithErrors();

        if (providerUsage.getErrors(cid) >= maxErrors) {

            logger.debug(String.format("Remove provider CID[%s] with error status, was exceeded limit on errors %d", cid, maxErrors));

            if (group.getPriority() > 0) {

                synchronized (groupsHighPriority) {
                    final Iterator<MatrixGroup<GID>> iterator = groupsHighPriority.iterator();
                    while (iterator.hasNext()) {
                        final MatrixGroup<GID> next = iterator.next();
                        if (next.getProviderCid() == cid) {
                            iterator.remove();
                            onFinishGroup(next.getGroupId(), UpdateResultType.INTERRUPTED_TOO_MANY_ERRORS);
                        }
                    }
                }

            } else {

                final List<MatrixGroup<GID>> providerGroups = groups.get(cid);
                if (providerGroups != null && !providerGroups.isEmpty()) {
                    // nothing to do with it
                    for (final MatrixGroup<GID> item : providerGroups) {
                        onFinishGroup(item.getGroupId(), UpdateResultType.INTERRUPTED_TOO_MANY_ERRORS);
                    }
                    providerGroups.clear();
                }

            }
        }
    }

    private void updateFinishedCoordinates(final MatrixGroup<GID> group, final GID groupId) {
        final Map<RoutesIterator.Type, List<DistanceMatrixCoordinate>> coordinates = group.getFinishedUpdateFor();

        if (coordinates != null) {
            for (final Map.Entry<RoutesIterator.Type, List<DistanceMatrixCoordinate>> entry : coordinates.entrySet()) {
                for (final DistanceMatrixCoordinate coordinate : entry.getValue()) {
                    listener.onFinishForCoordinate(
                            groupId, coordinate, entry.getKey(),
                            group.getProviderCid(), notFinishedRoutesOfGroupsWithErrors.contains(groupId)
                    );
                }
            }
        }

        notFinishedRoutesOfGroupsWithErrors.remove(groupId);
    }

    public void stopGroupUpdating(final GID groupId) {
        removedGroups.add(groupId);
    }

    public void changeRequestsDelay(final GID groupId, final int delay) {
        final MatrixGroup<GID> group = allGroups.get(groupId);
        if (group != null) {
            group.setRequestsDelay(delay);
        }
    }

    public boolean isHasErrors() {
        return hasErrors;
    }

    public void changeMatrixPriority(final GID groupId, final long priority) {
        final MatrixGroup<GID> group = allGroups.get(groupId);
        if (group != null) {
            group.setPriority(priority);

            // move from to highPriorityMatrices array
            if (!groupsHighPriority.contains(group)) {
                groupsHighPriority.add(group);
                // remove from groups
                final List<MatrixGroup<GID>> list = groups.get(group.getProviderCid());
                if (list != null) {
                    list.remove(group);
                }
            }

            Collections.sort(groupsHighPriority, HIGH_PRIORITY_MATRICES_COMPARATOR);
        }
    }

    @Override
    public String toString() {
        return "MatrixBuildTask{" +
                "groupsIds=" + groupsIds +
                ", busyTimeout=" + busyTimeout +
                ", requestTimeout=" + requestTimeout +
                ", maxErrors=" + maxErrors +
                ", groups=" + groups +
                ", providers=" + providers +
                ", providerUsage=" + providerUsage +
                ", listener=" + listener +
                ", waitBusyProviders=" + waitBusyProviders +
                ", hasErrors=" + hasErrors +
                '}';
    }

    /**
     * Contains settings per group of coordinates. GeoProviderSettings is mandatory, other parameters must have default
     * values
     */
    public static final class Settings {
        private final GeoProviderSettings geoProviderSettings;
        private Integer requestsDelay;
        private Integer coordinatesLimitPerBatchUpdate;
        private boolean allowBatchUpdates;
        private long priority;

        public Settings(final GeoProviderSettings geoProviderSettings) {
            this.geoProviderSettings = geoProviderSettings;
        }

        public GeoProviderSettings getGeoProviderSettings() {
            return geoProviderSettings;
        }

        public Integer getRequestsDelay() {
            return requestsDelay;
        }

        /**
         * Delay between requests to geo provider
         */
        public Settings withRequestsDelay(final Integer requestsDelay) {
            this.requestsDelay = requestsDelay;
            return this;
        }

        public Integer getCoordinatesLimitPerBatchUpdate() {
            return coordinatesLimitPerBatchUpdate;
        }

        /**
         * Maximum coordinates in batch requests
         */
        public Settings withCoordinatesLimitPerBatchUpdate(final Integer coordinatesLimitPerBatchUpdate) {
            this.coordinatesLimitPerBatchUpdate = coordinatesLimitPerBatchUpdate;
            return this;
        }

        public boolean getAllowBatchUpdates() {
            return allowBatchUpdates;
        }

        /**
         * Allow send batch requests to geo provider
         */
        public Settings withAllowBatchUpdates(final boolean allowBatchUpdates) {
            this.allowBatchUpdates = allowBatchUpdates;
            return this;
        }

        public long getPriority() {
            return priority;
        }

        /**
         * Priority of matrix.
         * If greater than zero, then time limits and provider availability (busy status) will be ignored.
         */
        public Settings withPriority(final long priority) {
            this.priority = priority;
            return this;
        }
    }
}
